Skip to main content

Cómo mantener activa una conexión HTTP durante 9 horas

Escrito por
feature keep http alive

23 de octubre de 2023

0 minutos de lectura

Se ha vuelto tan omnipresente que es fácil olvidar lo extraordinaria que es realmente la especificación HTTP. Cuando visitas un sitio web, como https://snyk.io, se desencadena una ráfaga de solicitudes HTTP adicionales para obtener JavaScript, imágenes, videos y otros recursos. Y, en cuestión de segundos, ves una página completamente renderizada. De hecho, el objetivo de cualquier sitio web dirigido a consumidores es entregar una página web completamente renderizada en unos pocos segundos como máximo; de lo contrario, podría perder tráfico frente a un sitio un poco más rápido (¡los segundos se acumulan!).

Sin embargo, a veces hay casos de uso para procesos más largos que envían actualizaciones periódicas a través de la conexión HTTP. Veamos uno de esos casos.

Cómo organiza Snyk un evento Capture the Flag

Cada año, Snyk organiza un evento Capture the Flag llamado Fetch the Flag (llamado así por nuestra mascota, Patch). Este año, nos entusiasma mucho que la leyenda de los CTF John Hammond sea el anfitrión del evento.

Por debajo, usamos la plataforma CTF de código abierto CTFd. CTFd tiene su propio sistema de registro e inicio de sesión. Sin embargo, queríamos usar nuestra propia página de registro para cuidar el estilo y el seguimiento. Estos son los requisitos de nuestro equipo de marketing:

  1. Registrarse exclusivamente a través de nuestra página de registro.

  2. Crear automáticamente una cuenta en el servidor de CTFd.

    1. Generar un alias único.

    2. Establecer una contraseña única y compleja.

    3. NO notificar a los usuarios todavía.

  3. Cuando se acerque el evento, iniciar un proceso para enviar por correo electrónico información sobre cómo obtener sus credenciales a todos los usuarios preregistrados.

    1. Cambiar el modo de registro de CTFd para que los nuevos usuarios reciban un correo electrónico con sus credenciales al registrarse.

En esta publicación, te cuento cómo el proyecto de código abierto que creé, ctfd-account-hook, evolucionó para admitir una solicitud HTTP segura y de larga duración, con la que notificamos por correo electrónico a casi 4,000 participantes registrados.

Cómo trabajar con la API de CTFd

El sistema CTFd tiene una API integrada para tareas comunes, como las operaciones CRUD para usuarios, además de un endpoint para enviar notificaciones por correo electrónico. Es fácil configurar el sistema para que use un proveedor de correo electrónico: solo hay que establecer el host, el puerto y las credenciales de autenticación.

Como ocurre con la mayoría de las API modernas, estos endpoints tienen límites de velocidad. En particular, las notificaciones por correo electrónico tienen límites de velocidad estrictos porque el servicio de correo electrónico configurado suele tener sus propios límites de velocidad de API. El endpoint de correo electrónico permite enviar 10 mensajes antes de devolver un código de estado HTTP estándar 429, que indica “demasiadas solicitudes”. Cuando haya transcurrido un minuto, puedes realizar otras 10 llamadas a la API de correo electrónico. Esta información fue útil para decidir cómo crear la aplicación ctfd-account-hook.

Cómo crear la aplicación CTFd account hook

El sistema que nuestro equipo de marketing usa para las páginas de registro puede realizar una llamada a la API cuando se envía el formulario de registro. Según los requisitos anteriores, sabía que necesitaba lo siguiente:

  1. Un endpoint seguro

  2. Datos mínimos para account hook: solo una dirección de correo electrónico

  3. La capacidad de cambiar de modo: de NO enviar notificaciones por correo electrónico al registrarse a enviarlas en ese momento

    1. Idealmente, NO habría que hacer cambios en la configuración de la página de registro cuando llegara el momento de cambiar de modo.

Me decidí por Spring Boot con Spring Security y WebFlux. Esto facilitó mucho la compatibilidad con endpoints seguros, las llamadas a la API de CTFd, la gestión de los límites de velocidad de la API y los cambios de configuración necesarios para admitir los dos modos de operación.

Crear cuentas

El único dato que recibe la aplicación ctfd-account-hook es una dirección de correo electrónico. La aplicación debe crear un alias único y, luego, una cuenta de usuario de CTFd mediante su API.

Nos decidimos por un sistema de alias que selecciona componentes de diccionarios internos. El alias consta de un adjetivo, un color y una raza de perro. Con 900 adjetivos, 52 colores y 80 razas de perro, hay 3,744,000 alias posibles.

El endpoint de la API de CTFd, /api/v1/users, se usa para crear un usuario. Tiene un parámetro opcional en la cadena de consulta: notify. Para crear un usuario y notificarle por correo electrónico sus credenciales al mismo tiempo, enviarías una solicitud POST como esta:

POST /api/v1/users?notify=true

Sin el parámetro notify en la cadena de consulta, el usuario NO recibirá una notificación por correo electrónico.

El primer beneficio concreto de usar Spring Boot quedó claro gracias a sus capacidades para manejar variables de entorno. En la clase CtfdApiServiceImpl hay un campo booleano llamado notifyOverride. Su valor se establece automáticamente mediante una variable de entorno con la siguiente sintaxis:

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

De forma predeterminada, notifyOverride se establecerá en false. Pero si la variable de entorno ctfd.api.notify-override se establece en true, todas las cuentas nuevas que se creen en CTFd también recibirán una notificación por correo electrónico. Esto se gestiona más adelante en el código:

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

La aplicación está implementada en Heroku y, cuando llegó el momento de cambiar al modo de envío de correo electrónico al crear una cuenta, bastó con cambiar una variable de entorno:

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

Envío masivo de correos electrónicos

Una vez que estuvo lista la creación de cuentas (con y sin notificación por correo electrónico), el siguiente gran desafío fue implementar el envío masivo de notificaciones por correo electrónico. El plan era permitir que las personas se registraran varias semanas antes de nuestro evento Fetch the Flag. Aunque se les asignaría una cuenta de CTFd (con un alias generado), NO recibirían sus credenciales.

Aproximadamente una semana antes del evento, cambiaríamos la configuración para que los nuevos registros recibieran una notificación por correo electrónico de inmediato. Luego, iniciaríamos un proceso de larga duración para enviar notificaciones a todos los usuarios que ya se habían registrado.

Este proceso de larga duración debía admitir el endpoint paginado de la API de CTFd para obtener una lista de los usuarios existentes y contar con una estrategia sensata de espera y reintentos para gestionar los límites de velocidad de la API. Aquí es donde brillan la compatibilidad con operaciones asíncronas de Spring Boot y el cliente HTTP de WebFlux. Veamos una solicitud a la API de WebFlux para el endpoint de correo electrónico de CTFd:

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

Esta línea, .retryWhen(retryBackoffSpec), garantiza que, cuando se alcancen los límites de velocidad de la API, se vuelva a intentar la solicitud de forma sensata. Esta es la definición 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());

En la primera línea, se usan las variables de entorno maxAttempts y backoffSeconds para controlar qué ocurre cuando se produce un error en la solicitud HTTP. Lo interesante es que esta definición contempla CUALQUIER tipo de error. El error más común sería un 429, que indica “demasiadas solicitudes”. Pero si se interrumpe el servicio y se devuelve un error del tipo 5xx, también se volverá a intentar la solicitud. Esto hace que las solicitudes web sean muy resilientes con muy poco código. Ese es el poder de WebFlux.

Una vez resuelta la estrategia de espera y reintentos, llegó el momento de configurar el proceso de larga duración para enviar notificaciones por correo electrónico. Sabía que habría que esperar 1 minuto cada 10 notificaciones y que teníamos alrededor de 4,000 registros; por eso, sabía que procesar todas las notificaciones tomaría más de 6.5 horas. En la práctica, al sumar el tiempo adicional de las llamadas a la API para la paginación, la actualización de contraseñas y el envío de notificaciones por correo electrónico, el proceso completo tardó más de 9 horas.

El siguiente paso fue implementar el manejo asíncrono. Quería que mi controlador respondiera de inmediato mientras iniciaba el proceso de larga duración. También quería mantener abierto el canal de la solicitud HTTP y enviar actualizaciones periódicas sobre el estado del proceso. Aquí es donde entran los Server Sent Events (SSE). Puedes pensar en SSE como una tubería abierta por la que podemos seguir enviando información. Un suscriptor recibirá esa información.

Spring Boot tiene compatibilidad integrada con SSE y la solicitud HTTP se suscribe automáticamente. Este es el código del controlador para iniciar el proceso de larga duración que envía las notificaciones por correo electrónico:

@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;
}

En la primera línea del método, se crea un objeto SseEmitter con un tiempo de espera de 24 horas. Se llama al método asíncrono ctfdApiService.updateAndEmail y se le pasa el emisor que se acaba de crear. Por último, se devuelve el emisor desde el método del controlador. Este método de controlador de tres líneas habilita el manejador SSE asíncrono. El método updateAndEmail enviará eventos periódicamente al emisor, que se transmitirán automáticamente a través de la solicitud HTTP abierta.

Antes de revisar el código del servicio, configuremos nuestra aplicación Spring Boot para que admita llamadas asíncronas. En la aplicación principal de Spring Boot, habilitas el manejo asíncrono mediante la anotación `EnableAsync`:

@SpringBootApplication
@EnableAsync
public class CtfdAccountHookApplication {

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

Luego, puedes hacer que un método de servicio sea asíncrono automáticamente con la anotación @Async. Esta es la definición del método updateAndEmail en la clase 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();
}

Spring Boot ejecuta este método en su propio hilo. Para cada página (una llamada a la API de CTFd), recorre la lista de usuarios registrados de esa página. Luego, para cada usuario, actualiza la contraseña (una llamada a la API de CTFd) y envía una notificación por correo electrónico (otra llamada a la API de CTFd). Durante todo el proceso, usa el emisor SSE para enviar mensajes por la tubería. La solicitud y su respuesta se ven más o menos así (con el 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

Al revisar los registros del servidor, vi algo como esto:

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

Aquí vemos en acción el mecanismo de espera y reintentos de nuestro cliente HTTP de WebFlux.

El obstáculo inesperado

Después de probar todo esto localmente, hice una prueba completa. Se ejecutó durante más de 9 horas y terminó sin problemas; además, tenía el registro completo de la salida SSE. Llegó el momento de implementar la aplicación en Heroku y ejecutarla de verdad.

Sabía que las cosas podían comportarse de manera distinta en un entorno de producción que en mi máquina local. Hice una prueba con unas 100 cuentas ficticias en Heroku y, para mi sorpresa, empecé a ver errores y la solicitud se interrumpió después de aproximadamente un minuto. Al parecer, Heroku cerraba mi solicitud HTTP porque llevaba demasiado tiempo inactiva.

Heroku tiene un proxy perimetral que hace que una aplicación implementada esté disponible en la Internet pública mediante una dirección HTTPS. Todo esto se configura automáticamente y hace que todas las aplicaciones implementadas estén protegidas con SSL de forma predeterminada. Para ofrecer un buen nivel de servicio a todas las aplicaciones que se ejecutan en Heroku, el proxy cierra las conexiones inactivas de manera agresiva. El problema de mi aplicación era que, cuando se activaba la lógica de espera y reintentos, no se enviaban notificaciones del emisor SSE durante un máximo de un minuto. Heroku cerraba la conexión inactiva después de unos 10 segundos. Para resolverlo, necesitaba otro método asíncrono que enviara un mensaje de “latido” SSE a intervalos regulares, sin importar qué ocurriera.

Este es el método actualizado del controlador del servicio:

@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 tanto emitterHeartBeat como updateAndEmail son métodos asíncronos, todo sigue funcionando según lo previsto. Este es el 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());
    }
}

Esto garantiza que cada 5 segundos se envíe un mensaje beat a través del emisor SSE por la solicitud HTTP abierta. Así, la conexión nunca parecerá inactiva y Heroku no la cerrará. Después de implementar este cambio, inicié el proceso de notificación de más de 9 horas y funcionó a la perfección.

¡A buscar la bandera!


Me enorgullece el proyecto ctfd-account-hook y ¡te invito a contribuir! Participo en Hacktoberfest, así que puedes obtener insignias si aceptan tus pull request. 

Lo que aprendí es que este proceso de larga duración funcionaría mucho mejor si se ejecutara completamente en segundo plano. Así, podría crear un endpoint para consultar su progreso. Aunque el protocolo Server Sent Event es muy interesante, el enfoque actual aún tiene cierta fragilidad. Si se interrumpe la solicitud HTTP abierta, todo el proceso puede fallar. Como se explica en este issue de GitHub, podría mantener prácticamente sin cambios mis procesos de servicio asíncronos. Una vez iniciado el proceso de larga duración, el controlador podría devolver de inmediato un job id. Un endpoint de consulta devolvería el estado del trabajo e indicaría si ya terminó. Aunque este enfoque agrega la complejidad de usar una base de datos y registros de tablas para hacer un seguimiento del progreso, elimina la fragilidad de mantener abierta una conexión HTTP.

Nos encantaría que participaras en nuestro evento Fetch the Flag el 27 de octubre de 2023. Después de registrarte, recibirás un correo electrónico con tus credenciales para la plataforma CTFd. Hay 30 desafíos y tendrás 24 horas para competir. Puedes formar un equipo nuevo o unirte a uno existente, y también puedes unirte al chat en nuestro servidor de Discord.

Publicado en: