Skip to main content

So bleibt eine HTTP-Verbindung 9 Stunden lang aktiv

Artikel von
feature keep http alive

23. Oktober 2023

0 Min. Lesezeit

HTTP ist so allgegenwärtig, dass man leicht vergisst, was für ein Meisterwerk die HTTP-Spezifikation ist. Wenn Sie eine Website wie https://snyk.io aufrufen, werden zahlreiche weitere HTTP-Anfragen ausgelöst, um JavaScript, Bilder, Videos und andere Ressourcen abzurufen. Und schon nach wenigen Sekunden sehen Sie eine vollständig gerenderte Seite. Tatsächlich besteht das Ziel jeder Website für Endverbraucher darin, eine vollständig gerenderte Webseite in höchstens wenigen Sekunden bereitzustellen. Andernfalls könnte sie Besucher an eine etwas schnellere Website verlieren (Sekunden summieren sich!).

Manchmal gibt es jedoch Anwendungsfälle für länger laufende Prozesse, die regelmäßig Updates über die HTTP-Verbindung senden. Sehen wir uns einen solchen Fall an.

So veranstaltet Snyk ein Capture-the-Flag-Event

Jedes Jahr veranstaltet Snyk ein Capture-the-Flag-Event namens Fetch the Flag (benannt nach unserem Maskottchen Patch). Dieses Jahr freuen wir uns besonders, dass die CTF-Legende John Hammond das Event moderiert.

Im Hintergrund nutzen wir die Open-Source-CTF-Plattform CTFd. CTFd verfügt über ein eigenes Registrierungs- und Anmeldesystem. Für Stil und Tracking wollten wir jedoch unsere eigene Registrierungs-Landingpage verwenden. Hier sind die Anforderungen unseres Marketingteams:

  1. Registrierung ausschließlich über unsere Registrierungs-Landingpage.

  2. Automatische Erstellung eines Kontos auf dem CTFd-Server.

    1. Einen eindeutigen Alias generieren.

    2. Ein eindeutiges, komplexes Passwort festlegen.

    3. Nutzerinnen und Nutzer vorerst NICHT benachrichtigen.

  3. Kurz vor dem Event einen Prozess starten, der allen vorregistrierten Nutzerinnen und Nutzern die Informationen zum Abruf ihrer Zugangsdaten per E-Mail sendet.

    1. Den CTFd-Registrierungsmodus umstellen, damit neue Nutzerinnen und Nutzer bei der Registrierung eine E-Mail mit ihren Zugangsdaten erhalten.

In diesem Beitrag zeige ich, wie sich das von mir entwickelte Open-Source-Projekt ctfd-account-hook weiterentwickelt hat, um eine lang laufende, sichere HTTP-Anfrage zu unterstützen und fast 4.000 registrierte Teilnehmende per E-Mail zu benachrichtigen.

Arbeiten mit der CTFd-API

Das CTFd-System verfügt über eine integrierte API für gängige Aufgaben wie CRUD-Operationen für Nutzerinnen und Nutzer sowie einen Endpunkt für E-Mail-Benachrichtigungen. Das System lässt sich einfach für die Verwendung eines E-Mail-Anbieters konfigurieren, indem Host, Port und Authentifizierungsdaten festgelegt werden.

Wie bei den meisten modernen APIs gibt es auch für diese Endpunkte Ratenbegrenzungen. Insbesondere gelten für E-Mail-Benachrichtigungen strenge API-Ratenbegrenzungen, da für den konfigurierten E-Mail-Dienst in der Regel eigene API-Ratenbegrenzungen gelten. Über den E-Mail-Endpunkt lassen sich 10 E-Mails versenden, bevor er den standardmäßigen HTTP-Statuscode 429 zurückgibt, der „zu viele Anfragen“ bedeutet. Nach Ablauf einer Minute können Sie erneut 10 E-Mail-API-Aufrufe ausführen. Diese Information war hilfreich bei der Entscheidung, wie die Anwendung ctfd-account-hook aufgebaut werden sollte.

Die CTFd-Account-Hook-Anwendung entwickeln

Das System, das unser Marketingteam für Registrierungsseiten verwendet, kann beim Absenden einer Registrierungsseite einen API-Aufruf ausführen. Auf Grundlage der obigen Anforderungen war mir klar, dass ich Folgendes wollte:

  1. Einen sicheren Endpunkt

  2. Minimale Eingaben für den Account-Hook – nur eine E-Mail-Adresse

  3. Die Möglichkeit, zwischen dem Modus ohne E-Mail-Benachrichtigung bei der Registrierung und dem Modus mit E-Mail-Benachrichtigung bei der Registrierung umzuschalten

    1. Idealerweise wären beim Umschalten keine Änderungen an der Konfiguration der Registrierungs-Landingpage erforderlich.

Ich entschied mich für Spring Boot mit Spring Security und WebFlux. Damit ließen sich sichere Endpunkte, API-Aufrufe an CTFd, die Einhaltung von API-Ratenbegrenzungen und einfache Konfigurationsänderungen zur Unterstützung der beiden Betriebsmodi problemlos umsetzen.

Konten erstellen

Die einzige Eingabe für die Anwendung ctfd-account-hook ist eine E-Mail-Adresse. Die Anwendung muss einen eindeutigen Alias erstellen und anschließend über die CTFd-API ein CTFd-Nutzerkonto anlegen.

Wir entschieden uns für ein Alias-System, das Bestandteile aus internen Wörterbüchern auswählt. Der Alias besteht aus einem Adjektiv, einer Farbe und einer Hunderasse. Mit 900 Adjektiven, 52 Farben und 80 Hunderassen stehen 3.744.000 mögliche Aliasse zur Auswahl.

Zum Erstellen eines Nutzers wird der CTFd-API-Endpunkt /api/v1/users verwendet. Er bietet den optionalen Abfrageparameter notify. Um einen Nutzer anzulegen und ihn gleichzeitig per E-Mail über seine Zugangsdaten zu informieren, würden Sie einen POST-Aufruf wie diesen senden:

POST /api/v1/users?notify=true

Ohne den Abfrageparameter notify erhält der Nutzer KEINE E-Mail-Benachrichtigung.

Der erste echte Vorteil von Spring Boot zeigte sich bei der Verarbeitung von Umgebungsvariablen. In der Klasse CtfdApiServiceImpl gibt es ein boolesches Feld namens notifyOverride. Sein Wert wird automatisch über eine Umgebungsvariable mit folgender Syntax festgelegt:

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

Standardmäßig wird notifyOverride auf false gesetzt. Ist die Umgebungsvariable ctfd.api.notify-override jedoch auf true gesetzt, erhalten alle neu erstellten CTFd-Konten auch eine E-Mail-Benachrichtigung. Weiter unten im Code wird dies so umgesetzt:

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

Die Anwendung wird auf Heroku bereitgestellt. Als es an der Zeit war, in den Modus für E-Mails bei der Kontoerstellung zu wechseln, war lediglich eine einfache Änderung der Umgebung erforderlich:

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

Massen-E-Mails versenden

Nachdem die Kontoerstellung mit und ohne E-Mail-Benachrichtigung funktionierte, bestand die nächste große Herausforderung darin, den Versand von E-Mail-Benachrichtigungen in großem Umfang umzusetzen. Geplant war, Registrierungen mehrere Wochen vor unserem Event Fetch the Flag zu ermöglichen. Den Teilnehmenden sollte zwar ein CTFd-Konto mit einem generierten Alias zugewiesen werden, sie sollten aber NICHT über ihre Zugangsdaten benachrichtigt werden.

Etwa eine Woche vor dem Event sollte der Modus umgestellt werden, sodass neue Registrierungen sofort eine E-Mail-Benachrichtigung erhalten. Anschließend sollte ein lang laufender Prozess gestartet werden, um alle zuvor registrierten Nutzerinnen und Nutzer zu benachrichtigen.

Dieser lang laufende Prozess musste den paginierten CTFd-API-Endpunkt zum Abrufen einer Liste vorhandener Nutzer unterstützen und einen sinnvollen Backoff-/Retry-Ansatz für den Umgang mit API-Ratenbegrenzungen bieten. Hier kommen die Async-Unterstützung von Spring Boot und der HTTP-Client von WebFlux richtig zur Geltung. Sehen wir uns eine WebFlux-API-Anfrage an den CTFd-E-Mail-Endpunkt an:

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

Diese eine Zeile – .retryWhen(retryBackoffSpec) – sorgt dafür, dass die Anfrage bei Erreichen der API-Ratenbegrenzung auf sinnvolle Weise erneut ausgeführt wird. Hier ist die Definition von 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());

In der ersten Zeile werden die Umgebungsvariablen maxAttempts und backoffSeconds verwendet, um festzulegen, was bei einem Fehler der HTTP-Anfrage geschieht. Das Gute daran: Diese Definition deckt JEDE Art von Fehler ab. Am häufigsten tritt der Fehler 429 auf, der „zu viele Anfragen“ bedeutet. Bei einer Dienstunterbrechung und einem zurückgegebenen 5xx-Fehler wird die Anfrage jedoch ebenfalls wiederholt. Dadurch sind die Webanfragen mit sehr wenig Code äußerst robust. Das ist die Stärke von WebFlux.

Nachdem der Backoff-/Retry-Ansatz umgesetzt war, konnte ich den lang laufenden E-Mail-Benachrichtigungsprozess einrichten. Da nach jeweils 10 E-Mail-Benachrichtigungen eine Minute Wartezeit anfiel und wir etwa 4.000 Registrierungen hatten, war mir klar, dass die Verarbeitung aller Benachrichtigungen mehr als 6,5 Stunden dauern würde. Mit dem zusätzlichen Aufwand für API-Aufrufe zur Paginierung, Passwortaktualisierung und E-Mail-Benachrichtigung dauerte der gesamte Prozess in der Praxis mehr als 9 Stunden.

Als Nächstes wollte ich die asynchrone Verarbeitung implementieren. Mein Controller sollte sofort zurückkehren und gleichzeitig den lang laufenden Prozess starten. Außerdem wollte ich die HTTP-Anfrage offen halten und regelmäßig Status-Updates zum Prozess senden. Hier kommen Server Sent Events (SSE) ins Spiel. Sie können sich SSE als eine offene Leitung vorstellen, über die wir fortlaufend Informationen senden können. Ein Abonnent empfängt diese Informationen.

Spring Boot unterstützt SSE standardmäßig, und die HTTP-Anfrage wird automatisch dafür registriert. Hier ist der Controller-Code zum Starten des lang laufenden E-Mail-Benachrichtigungsprozesses:

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

In der ersten Zeile der Methode wird ein SseEmitter-Objekt mit einem Timeout von 24 Stunden erstellt. Die asynchrone Methode ctfdApiService.updateAndEmail wird aufgerufen und erhält den neu erstellten Emitter als Argument. Abschließend gibt die Controller-Methode den Emitter zurück. Diese dreizeilige Controller-Methode aktiviert die asynchrone SSE-Verarbeitung. Die Methode updateAndEmail sendet regelmäßig Events an den Emitter, der sie automatisch über die offene HTTP-Anfrage überträgt.

Bevor wir uns den Service-Code ansehen, richten wir unsere Spring-Boot-Anwendung für asynchrone Aufrufe ein. In der Hauptanwendung von Spring Boot aktivieren Sie die asynchrone Verarbeitung über die Annotation `EnableAsync`:

@SpringBootApplication
@EnableAsync
public class CtfdAccountHookApplication {

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

Anschließend lässt sich eine Service-Methode automatisch asynchron ausführen, indem Sie sie mit @Async annotieren. Hier ist die Definition der Methode updateAndEmail in der Klasse 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 führt diese Methode in einem eigenen Thread aus. Für jede Seite (einen CTFd-API-Aufruf) durchläuft die Methode die Liste der registrierten Nutzerinnen und Nutzer auf dieser Seite. Für jeden Nutzer aktualisiert sie anschließend das Passwort (ein CTFd-API-Aufruf) und versendet eine E-Mail-Benachrichtigung (ein CTFd-API-Aufruf). Dabei verwendet sie den SSE-Emitter, um Nachrichten über die Leitung zu senden. Die Anfrage und ihre Ausgabe sehen in etwa so aus (mit dem Client 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

In den Server-Logs sah ich etwa Folgendes:

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

Hier sehen wir den Backoff-/Retry-Mechanismus unseres WebFlux-HTTP-Clients in Aktion.

Der Haken an der Sache

Nachdem ich alles lokal getestet hatte, führte ich einen vollständigen Testlauf durch. Er dauerte mehr als 9 Stunden, lief problemlos durch und lieferte mir das vollständige SSE-Ausgabeprotokoll. Nun war es an der Zeit, die Anwendung auf Heroku bereitzustellen und im Echtbetrieb laufen zu lassen.

Da sich eine Produktionsumgebung anders verhalten kann als mein lokaler Rechner, führte ich auf Heroku einen Testlauf mit etwa 100 Dummy-Konten durch. Zu meiner Überraschung traten dabei Fehler auf, und die Anfrage brach nach etwa einer Minute ab. Offenbar beendete Heroku meine HTTP-Anfrage, weil sie zu lange inaktiv war.

Heroku verfügt über einen Edge-Proxy, der dafür sorgt, dass eine bereitgestellte Anwendung über eine HTTPS-Adresse im öffentlichen Internet erreichbar ist. All dies wird automatisch konfiguriert, sodass standardmäßig alle bereitgestellten Anwendungen durch SSL geschützt sind. Um allen auf Heroku laufenden Anwendungen eine hohe Dienstqualität zu bieten, schließt Heroku inaktive Verbindungen konsequent. Das Problem meiner Anwendung: Wenn die Backoff-/Retry-Logik ausgelöst wurde, sendete der SSE-Emitter bis zu einer Minute lang keine Benachrichtigungen. Heroku schließt inaktive Verbindungen nach etwa 10 Sekunden. Um das zu verhindern, brauchte ich eine weitere asynchrone Methode, die unabhängig von allen anderen Ereignissen in regelmäßigen Abständen eine SSE-„Heartbeat“-Nachricht sendet.

Hier ist die aktualisierte Controller-Methode des Service:

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

Da sowohl emitterHeartBeat als auch updateAndEmail asynchrone Methoden sind, funktioniert weiterhin alles wie erwartet. Hier ist die Methode 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());
    }
}

Damit wird sichergestellt, dass alle 5 Sekunden eine beat-Nachricht über den SSE-Emitter durch die offene HTTP-Anfrage gesendet wird. Die Verbindung erscheint dadurch nie inaktiv, und Heroku schließt sie folglich nicht. Nachdem ich diese Änderung bereitgestellt hatte, startete ich meinen Benachrichtigungsprozess, der mehr als 9 Stunden dauerte – und alles lief reibungslos.

Auf geht’s zur Flaggenjagd


Ich bin stolz auf das Projekt ctfd-account-hook und freue mich über Beiträge dazu! Ich nehme am Hacktoberfest teil. Für angenommene Pull Requests können Sie Badges erhalten. 

Ich habe gelernt, dass sich dieser langwierige Prozess viel besser vollständig im Hintergrund ausführen ließe. Dann könnte ich einen Endpunkt schreiben, der den Fortschritt abfragt. So interessant das Server-Sent-Events-Protokoll auch ist, der aktuelle Ansatz ist nach wie vor anfällig. Wird die offene HTTP-Anfrage unterbrochen, kann der gesamte Prozess fehlschlagen. Wie in diesem Issue auf GitHub beschrieben, könnte ich meine asynchronen Service-Prozesse weitgehend unverändert beibehalten. Sobald der langwierige Prozess gestartet ist, könnte der Controller sofort eine job id zurückgeben. Ein Abfrage-Endpunkt würde den Status des Jobs zurückgeben, einschließlich eines Hinweises darauf, dass er abgeschlossen ist. Dieser Ansatz ist zwar komplexer, da eine Datenbank und Tabelleneinträge erforderlich sind, um den Fortschritt zu verfolgen, doch beseitigt er die Anfälligkeit einer offenen HTTP-Verbindung.

Wir würden uns freuen, wenn Sie am 27. Oktober 2023 bei unserem Event Fetch the Flag dabei sind. Nach der Registrierung erhalten Sie per E-Mail Ihre Zugangsdaten für die CTFd-Plattform. Es gibt 30 Challenges, und Sie haben 24 Stunden Zeit, um daran teilzunehmen. Sie können ein neues Team bilden oder einem bestehenden beitreten und sich in unserem Discord-Server am Chat beteiligen.

Gepostet in: