Skip to main content

Como manter uma conexão HTTP ativa por 9 horas

Escrito por
feature keep http alive

23 de outubro de 2023

0 minutos de leitura

O HTTP se tornou tão onipresente que é fácil esquecer o quanto sua especificação é impressionante. Quando você acessa um site, como https://snyk.io, isso dispara uma série de requisições HTTP adicionais para buscar JavaScript, imagens, vídeos e outros recursos. Em poucos segundos, a página aparece totalmente renderizada. Na verdade, o objetivo de qualquer site voltado ao público é exibir uma página web completamente renderizada em poucos segundos, no máximo; caso contrário, pode perder tráfego para um site um pouco mais rápido (os segundos fazem diferença!).

Às vezes, porém, há situações em que um processo mais longo precisa enviar atualizações regulares pela conexão HTTP. Vamos conhecer um desses casos.

Como a Snyk organiza um evento Capture the Flag

Todos os anos, a Snyk organiza um evento Capture the Flag chamado Fetch the Flag (o nome é uma referência ao nosso mascote, Patch). Neste ano, estamos muito animados por contar com a lenda dos CTFs John Hammond como anfitrião do evento.

Nos bastidores, usamos a plataforma de CTF de código aberto CTFd. O CTFd tem seu próprio sistema de inscrição e login. Porém, queríamos usar nossa própria página de inscrição para fins de estilo e rastreamento. Estes foram os requisitos da nossa equipe de marketing:

  1. Fazer a inscrição exclusivamente pela nossa página de inscrição.

  2. Criar automaticamente uma conta no servidor CTFd.

    1. Gerar um alias exclusivo.

    2. Definir uma senha exclusiva e complexa.

    3. NÃO enviar notificações aos usuários ainda.

  3. Quando o evento estiver próximo, iniciar um processo para enviar e-mails em massa a todos os usuários pré-inscritos, com informações para obter suas credenciais.

    1. Alterar o modo de inscrição do CTFd para que novos usuários recebam um e-mail com as credenciais no momento da inscrição.

Neste post, vou mostrar como o projeto de código aberto que criei — ctfd-account-hook — evoluiu para oferecer suporte a uma requisição HTTP segura e de longa duração, usada para enviar um aviso por e-mail a quase 4.000 participantes inscritos.

Trabalhando com a API do CTFd

O CTFd tem uma API integrada para tarefas comuns, como operações CRUD de usuários, além de um endpoint para notificações por e-mail. É fácil configurar o sistema para usar um provedor de e-mail: basta definir o host, a porta e as credenciais de autenticação.

Como acontece com a maioria das APIs modernas, esses endpoints têm limites de taxa. Em particular, o endpoint de notificação por e-mail tem limites de taxa rigorosos, porque o serviço de e-mail configurado geralmente também tem seus próprios limites de taxa de API. O endpoint de e-mail permite enviar 10 mensagens antes de retornar o código de status HTTP padrão 429, que indica “requisições demais”. Depois de um minuto, você pode fazer um novo conjunto de 10 chamadas à API de e-mail. Essa informação foi útil para decidir como criar o aplicativo ctfd-account-hook.

Criando o app de integração de contas do CTFd

O sistema que nossa equipe de marketing usa para criar páginas de inscrição permite fazer uma chamada à API quando o formulário é enviado. Com base nos requisitos acima, sabia que precisava de:

  1. Um endpoint seguro

  2. O mínimo de dados de entrada para a integração de contas: apenas um endereço de e-mail

  3. A possibilidade de alternar entre não enviar notificações por e-mail no momento da inscrição e enviá-las nesse momento

    1. De preferência, nenhuma alteração na configuração da página de inscrição na hora de alternar entre os modos.

Optei por Spring Boot com Spring Security e WebFlux. Assim, ficou muito fácil oferecer suporte a endpoints seguros, fazer chamadas à API do CTFd, lidar com os limites de taxa da API e alterar a configuração para dar suporte aos dois modos de operação.

Criando contas

O único dado de entrada para o app ctfd-account-hook é um endereço de e-mail. O app precisa criar um alias exclusivo e, em seguida, criar uma conta de usuário no CTFd usando a API.

Optamos por um sistema de aliases que seleciona componentes de dicionários internos. O alias é composto por um adjetivo, uma cor e uma raça de cachorro. Com 900 adjetivos, 52 cores e 80 raças de cachorro, há um total de 3.744.000 aliases possíveis.

O endpoint da API do CTFd — /api/v1/users — é usado para criar um usuário. Ele tem um parâmetro opcional na string de consulta: notify. Para criar um usuário e enviar por e-mail suas credenciais ao mesmo tempo, você faria uma requisição POST como esta:

POST /api/v1/users?notify=true

Sem o parâmetro notify na string de consulta, o usuário NÃO receberá uma notificação por e-mail.

O primeiro benefício prático do Spring Boot veio dos recursos de gerenciamento de variáveis de ambiente. Na classe CtfdApiServiceImpl, há um campo booleano chamado notifyOverride. O valor é definido automaticamente por uma variável de ambiente usando a seguinte sintaxe:

@Value("#{ @environment['ctfd.api.notify-override'] ?: false }")
private Boolean notifyOverride;

Por padrão, notifyOverride é definido como false. Mas, se a variável de ambiente ctfd.api.notify-override for definida como true, toda nova conta criada no CTFd também receberá uma notificação por e-mail. Isso é tratado mais adiante no código:

…
String notify = (notifyOverride || req.getNotify()) ? "?notify=true" : "";
String uri = API_URI + "/users" + notify;

O app é implantado no Heroku e, quando chegou a hora de mudar para o modo que envia e-mails na criação de contas, bastou alterar uma variável de ambiente:

heroku config:set ctfd.api.notify-override=true

Enviando e-mails em massa

Com a criação de contas funcionando, com e sem notificações por e-mail, o próximo grande desafio foi implementar o envio de notificações em massa. O plano era permitir que as pessoas se inscrevessem algumas semanas antes do evento Fetch the Flag. Embora uma conta CTFd fosse criada para cada pessoa (com um alias gerado), elas NÃO receberiam suas credenciais.

Cerca de uma semana antes do evento, mudaríamos a configuração para que as novas inscrições recebessem uma notificação por e-mail imediatamente. Em seguida, iniciaríamos um processo de longa duração para enviar notificações a todos os usuários que já haviam se inscrito.

Esse processo de longa duração precisava oferecer suporte ao endpoint paginado da API do CTFd para obter a lista de usuários existentes e a uma estratégia adequada de espera progressiva e novas tentativas para lidar com os limites de taxa da API. É aí que o suporte assíncrono do Spring Boot e o cliente HTTP WebFlux se destacam. Vamos ver uma requisição à API do endpoint de e-mail do CTFd usando WebFlux:

this.webClient.post().uri(uri)
    .bodyValue(emailText)
    .retrieve()
    …
    .bodyToMono(CtfdUserResponse.class)
    .retryWhen(retryBackoffSpec)
    .block();

Esta linha — .retryWhen(retryBackoffSpec) — garante que, quando os limites de taxa da API forem atingidos, a requisição será repetida de forma adequada. Veja a definição de retryBackoffSpec:

this.retryBackoffSpec = Retry.backoff(maxAttempts, Duration.ofSeconds(backoffSeconds))
    .doBeforeRetry(retrySignal -> log.debug(
        "Waiting {} seconds. Retry #{} of {} after exception: {}",
        backoffSeconds, (retrySignal.totalRetriesInARow()+1), maxAttempts,
        retrySignal.failure().getLocalizedMessage()
    ))
    .onRetryExhaustedThrow((retryBackoffSpec, retrySignal) -> retrySignal.failure());

Na primeira linha, as variáveis de ambiente maxAttempts e backoffSeconds controlam o que acontece quando ocorre um erro na requisição HTTP. O interessante é que essa definição cobre QUALQUER tipo de erro. O mais comum seria um erro 429, que indica “requisições demais”. Mas, se houver uma interrupção no serviço e for retornado um erro do tipo 5xx, a requisição também será repetida. Isso torna as requisições web muito resilientes com pouquíssimo código. Esse é o poder do WebFlux.

Com a estratégia de espera progressiva e novas tentativas pronta, chegou a hora de configurar o processo de longa duração para enviar as notificações por e-mail. Sabendo que a cada 10 notificações haveria uma espera de 1 minuto e que tínhamos cerca de 4.000 inscrições, calculei que levaria mais de 6,5 horas para processar todas as notificações. Na prática, considerando também o tempo adicional das chamadas à API para paginação, atualização de senha e envio de notificações por e-mail, o processo todo levou mais de 9 horas.

O próximo passo era implementar o processamento assíncrono. Queria que meu controlador retornasse imediatamente enquanto iniciava o processo de longa duração. Também queria manter aberto o canal da requisição HTTP e enviar atualizações periódicas sobre o andamento do processo. É aí que entram os Server Sent Events (SSE). Pense nos SSE como um canal aberto pelo qual podemos continuar enviando informações. Quem estiver inscrito as receberá.

O Spring Boot tem suporte integrado a SSE, e a requisição HTTP é inscrita automaticamente. Veja o código do controlador que inicia o processo de longa duração para enviar notificações por e-mail:

@PostMapping("/api/v1/update-and-email/{affiliation}")
public SseEmitter updateAndEmailUsers(@PathVariable String affiliation) {
    SseEmitter emitter = new SseEmitter(1000*60*60*24L);
    ctfdApiService.updateAndEmail(emitter, affiliation);
    return emitter;
}

Na primeira linha do método, é criado um objeto SseEmitter com um tempo limite de 24 horas. O método assíncrono ctfdApiService.updateAndEmail é chamado e recebe o emissor recém-criado. Por fim, o emissor é retornado pelo método do controlador. Esse método de controlador de três linhas habilita o manipulador assíncrono de SSE. O método updateAndEmail envia eventos periodicamente ao emissor, que os encaminha automaticamente pela requisição HTTP aberta.

Antes de vermos o código do serviço, vamos configurar o aplicativo Spring Boot para dar suporte a chamadas assíncronas. No aplicativo principal do Spring Boot, você ativa o processamento assíncrono usando a anotação `EnableAsync`:

@SpringBootApplication
@EnableAsync
public class CtfdAccountHookApplication {

    public static void main(String[] args) {
        SpringApplication.run(CtfdAccountHookApplication.class, args);
    }
}

Em seguida, basta adicionar a anotação @Async a um método de serviço para torná-lo assíncrono. Veja a definição do método updateAndEmail na classe CtfdApiServiceImpl:

    @Async
    @Override
    public void updateAndEmail(SseEmitter emitter, String affiliation) {
        Integer page = 1;
        int processed = 0;

        do {
            try {
                CtfdUserPaginatedResponse ctfdUserResponse =
                    getUsersByAffiliation(affiliation, page);
                for (CtfdUser ctfdUser : ctfdUserResponse.getData()) {
                    SseEmitter.SseEventBuilder  event = SseEmitter.event()
                        .data("Processing - " + ctfdUser.getId() + " - " + LocalTime.now().toString())
                        .id(String.valueOf(ctfdUser.getId()))
                        .name(ctfdUser.getId() + " - " + ctfdUser.getName());
                    emitter.send(event);
…
                    ctfdUser = updatePassword(ctfdUser);
                    emailUser(ctfdUser);
                }
                page = ctfdUserResponse.getMeta().getPagination().getNext();
                processed += ctfdUserResponse.getData().length;
…
            } catch (Exception e) {
                log.error("Failure while update/email operation: {}", e.getMessage());
                emitter.completeWithError(e);
                return;
            }
        } while (page != null);
…
        emitter.complete();
}

O Spring Boot executa esse método em uma thread própria. Para cada página (uma chamada à API do CTFd), ele percorre a lista de usuários inscritos daquela página. Em seguida, para cada usuário, atualiza a senha (uma chamada à API do CTFd) e envia uma notificação por e-mail (outra chamada à API do CTFd). Durante o processo, usa o emissor SSE para enviar mensagens pelo canal. A requisição e sua saída são mais ou menos assim (usando o cliente HTTPie):

http POST \
https://<ctfd account hook url>/api/v1/update-and-email/fetch2023 \
 x-api-key:"<api token>"

HTTP/1.1 200
data:Processing - 1 - 17:19:00.595414016
id:1
event:1 - raw-blue-armant

data:Finished Processing - 1 - 17:19:03.958247179
id:1
event:1 - raw-blue-armant

Ao consultar os logs do servidor, vi algo assim:

Processing user id: 1, name: raw-blue-armant
Password updated for user id: 1
Email sent for user id: 1
…
Processing user id: 10, name: conscious-harlequin-cursinu
Password updated for user id: 10
Waiting 10 seconds. Retry #1 of 10 after exception: 429 Too Many Requests from POST https://snyk.ctf.games/api/v1/users/10/email
Waiting 10 seconds. Retry #2 of 10 after exception: 429 Too Many Requests from POST https://snyk.ctf.games/api/v1/users/10/email
Waiting 10 seconds. Retry #3 of 10 after exception: 429 Too Many Requests from POST https://snyk.ctf.games/api/v1/users/10/email
Email sent for user id: 10

Aqui, vemos em ação o mecanismo de espera progressiva e novas tentativas do nosso cliente HTTP WebFlux.

O imprevisto

Depois de testar tudo localmente, fiz um teste completo. Ele levou mais de 9 horas, terminou sem problemas e gerou o log completo da saída SSE. Chegou a hora de implantar no Heroku e executar o processo de verdade.

Sabendo que as coisas podem se comportar de maneira diferente em produção e na minha máquina local, fiz um teste no Heroku com cerca de 100 contas fictícias. Para minha surpresa, começaram a aparecer erros, e a conexão foi encerrada após cerca de 1 minuto. Pelo visto, o Heroku estava encerrando minha requisição HTTP por ficar ociosa por muito tempo.

O Heroku tem um proxy de borda que disponibiliza automaticamente os aplicativos implantados na internet pública por meio de um endereço HTTPS. Tudo é configurado automaticamente, e todos os aplicativos implantados são protegidos por SSL por padrão. Para oferecer um serviço de qualidade a todos os aplicativos executados no Heroku, o proxy encerra conexões ociosas de forma agressiva. O problema do meu app era que, quando a lógica de espera progressiva e novas tentativas era acionada, o emissor SSE podia ficar até um minuto sem enviar notificações. O Heroku encerra a conexão ociosa após cerca de 10 segundos. Para contornar isso, eu precisava de outro método assíncrono que enviasse periodicamente uma mensagem SSE de “batimento”, independentemente do que estivesse acontecendo.

Veja o método atualizado do controlador de serviço:

@PostMapping("/api/v1/update-and-email/{affiliation}")
public SseEmitter updateAndEmailUsers(@PathVariable String affiliation) {
    // TODO - should probs be another env var setting
    SseEmitter emitter = new SseEmitter(1000*60*60*24L);
    ctfdApiService.emitterHeartBeat(emitter);
    ctfdApiService.updateAndEmail(emitter, affiliation);
    return emitter;
}

Como emitterHeartBeat e updateAndEmail são métodos assíncronos, tudo continua funcionando como esperado. Veja o método emitterHeartBeat:

@Async
@Override
public void emitterHeartBeat(SseEmitter emitter) {
    try {
        do {
            emitter.send("beat");
            Thread.sleep(5000);
        } while (true);
    } catch (Exception e) {
        log.debug("exception during emitter: {}", e.getMessage());
    }
}

Isso garante que, a cada 5 segundos, uma mensagem beat seja enviada pela requisição HTTP aberta por meio do emissor SSE. Assim, a conexão nunca fica ociosa e o Heroku não a encerra. Depois de implantar essa alteração, iniciei meu processo de notificações, que durava mais de 9 horas, e tudo funcionou perfeitamente.

Agora é só buscar a bandeira


Tenho orgulho do projeto ctfd-account-hook e adoraria receber contribuições! Estou participando do Hacktoberfest, então você pode ganhar badges ao ter seus pull requests aceitos.

O que aprendi é que esse processo de longa duração funcionaria muito melhor totalmente em segundo plano. Assim, eu poderia criar um endpoint para consultar o andamento. Por mais interessante que seja o protocolo Server-Sent Events, a abordagem atual ainda é um pouco frágil. Se houver uma interrupção na solicitação HTTP aberta, todo o processo pode falhar. Como descrito nesta issue no GitHub, eu poderia manter praticamente como estão os processos assíncronos do meu serviço. Depois que o processo de longa duração fosse iniciado, o controlador poderia retornar imediatamente um job id. Um endpoint de consulta retornaria o status do job, incluindo a indicação de que ele foi concluído. Embora essa abordagem acrescente a complexidade de usar um banco de dados e registros em uma tabela para acompanhar o progresso, ela elimina a fragilidade de uma conexão HTTP aberta.

Adoraríamos contar com a sua participação no evento Fetch the Flag, em 27 de outubro de 2023. Depois que você se inscrever, receberá um e-mail com suas credenciais para a plataforma CTFd. São 30 desafios e você terá 24 horas para competir. Você pode criar uma equipe ou entrar em uma já existente, além de participar do chat no nosso servidor do Discord.

Publicado em: