OpenSSL

密码学和SSL/TLS工具包

ossl-guide-quic-client-non-block

名称

ossl-guide-quic-client-non-block - OpenSSL指南:编写一个简单的非阻塞QUIC客户端

简单的非阻塞QUIC客户端示例

本页面将基于ossl-guide-quic-client-block(7)页面上开发的示例进行构建,该示例演示了如何编写一个简单的阻塞QUIC客户端。在本页面上,我们将修改该演示代码,使其支持非阻塞功能。

此非阻塞QUIC客户端示例的完整源代码可在OpenSSL源代码分发版的demos/guide目录中的quic-client-non-block.c文件中找到。它也可以在https://github.com/openssl/openssl/blob/master/demos/guide/quic-client-non-block.c在线获取。

正如我们在前面的示例中看到的,OpenSSL QUIC应用程序始终使用非阻塞套接字。但是,尽管如此,SSL对象仍然具有阻塞行为。当SSL对象具有阻塞行为时,这意味着如果您尝试在没有数据的情况下读取它,它将等待(阻塞)直到有数据可读。类似地,如果SSL对象当前无法写入,它将在写入时等待。这可以简化代码的开发,因为您不必担心在这些情况下该做什么。代码的执行将简单地停止,直到它能够继续。但是,在许多情况下,您不希望这种行为。您的应用程序可能需要在SSL对象无法读/写时执行其他任务,例如更新GUI或对其他连接或流执行操作,而不是停止等待。

我们将在本教程的后面看到如何更改SSL对象,使其具有非阻塞行为。对于非阻塞SSL对象,如果函数(例如SSL_read_ex(3)SSL_write_ex(3))当前无法读取或写入,则将立即返回一个非致命错误。

由于此页面基于ossl-guide-quic-client-block(7)页面上开发的示例,因此我们假设您熟悉它,并且我们只解释此示例的不同之处。

在等待套接字时执行工作

在非阻塞应用程序中,如果我们想要读取或写入SSL对象但当前无法做到,则需要执行工作。事实上,这就是使用非阻塞SSL对象的全部意义所在,即让应用程序有机会执行其他操作。无论应用程序必须做什么,它还必须准备好定期返回并重试之前尝试的操作,以查看它现在是否可以完成。理想情况下,它只会在线程状态发生变化从而可能在重试尝试中成功时才执行此操作,但这并非必须如此。它可以随时重试。

请注意,重试您上次尝试的完全相同的操作非常重要。您无法开始新的操作。例如,如果您尝试写入文本“Hello World”,并且操作失败,因为SSL对象当前无法写入,那么您在重试操作时不能尝试写入其他文本。

在此演示应用程序中,我们将创建一个辅助函数来模拟执行其他工作。实际上,为了简单起见,它除了等待底层套接字的状态更改或超时过期(之后SSL对象的状态可能已更改)之外什么也不做。我们将调用我们的函数wait_for_activity()

    static void wait_for_activity(SSL *ssl)
    {
        fd_set wfds, rfds;
        int width, sock, isinfinite;
        struct timeval tv;
        struct timeval *tvp = NULL;

        /* Get hold of the underlying file descriptor for the socket */
        sock = SSL_get_fd(ssl);

        FD_ZERO(&wfds);
        FD_ZERO(&rfds);

        /*
         * Find out if we would like to write to the socket, or read from it (or
         * both)
         */
        if (SSL_net_write_desired(ssl))
            FD_SET(sock, &wfds);
        if (SSL_net_read_desired(ssl))
            FD_SET(sock, &rfds);
        width = sock + 1;

        /*
         * Find out when OpenSSL would next like to be called, regardless of
         * whether the state of the underlying socket has changed or not.
         */
        if (SSL_get_event_timeout(ssl, &tv, &isinfinite) && !isinfinite)
            tvp = &tv;

        /*
         * Wait until the socket is writeable or readable. We use select here
         * for the sake of simplicity and portability, but you could equally use
         * poll/epoll or similar functions
         *
         * NOTE: For the purposes of this demonstration code this effectively
         * makes this demo block until it has something more useful to do. In a
         * real application you probably want to go and do other work here (e.g.
         * update a GUI, or service other connections).
         *
         * Let's say for example that you want to update the progress counter on
         * a GUI every 100ms. One way to do that would be to use the timeout in
         * the last parameter to "select" below. If the tvp value is greater
         * than 100ms then use 100ms instead. Then, when select returns, you
         * check if it did so because of activity on the file descriptors or
         * because of the timeout. If the 100ms GUI timeout has expired but the
         * tvp timeout has not then go and update the GUI and then restart the
         * "select" (with updated timeouts).
         */

        select(width, &rfds, &wfds, NULL, tvp);
}

如果您熟悉如何在OpenSSL中为TLS编写非阻塞应用程序(请参阅ossl-guide-tls-client-non-block(7)),那么您应该注意,此处QUIC应用程序和TLS应用程序的工作方式之间存在重要差异。对于TLS应用程序,如果我们尝试读取或写入SSL对象的内容并收到“重试”响应(SSL_ERROR_WANT_READSSL_ERROR_WANT_WRITE),那么我们可以假设这是因为OpenSSL尝试读取或写入底层套接字,并且套接字发出了“重试”信号。对于QUIC,情况并非如此。OpenSSL可能会由于SSL_read_ex(3)SSL_write_ex(3)(或类似)调用而发出重试信号,这表示流的状态。这与底层套接字是否需要重试完全无关。

要确定OpenSSL当前是否希望读取或写入QUIC应用程序的底层套接字,我们必须调用SSL_net_read_desired(3)SSL_net_write_desired(3)函数。

对于QUIC,定期调用I/O函数(或以其他方式调用SSL_handle_events(3)函数)以确保QUIC连接保持健康状态也很重要。对于非阻塞应用程序,这一点尤其重要,因为在应用程序执行其他工作时,您可能会让SSL对象闲置一段时间。SSL_get_event_timeout(3)函数可用于确定下次我们需要调用I/O函数(或调用SSL_handle_events(3))的截止日期。

使用SSL_get_event_timeout(3)查找OpenSSL必须再次调用的下一个截止日期的替代方法是使用“线程辅助”模式。在“线程辅助”模式下,OpenSSL会生成一个额外的线程,该线程将定期自动调用SSL_handle_events(3),这意味着应用程序可以放心地使连接保持空闲,因为知道连接仍将保持健康状态。有关此内容的更多详细信息,请参阅下面的“创建SSL_CTX和SSL对象”

在此示例中,我们使用select函数来检查套接字的可读性/可写性,因为它非常易于使用,并且在大多数操作系统上都可用。但是,您可以使用任何其他类似的函数来执行相同操作。select等待底层套接字的状态变为可读/可写,或直到超时过期然后返回。

处理来自OpenSSL I/O函数的错误

已配置为非阻塞行为的QUIC应用程序需要准备好处理从OpenSSL I/O函数(如SSL_read_ex(3)SSL_write_ex(3))返回的错误。错误可能是流的致命错误(例如,因为流已重置或底层连接已失败),也可能是非致命错误(例如,因为我们正在尝试从流中读取但尚未从对等方收到该流的数据)。

SSL_read_ex(3)SSL_write_ex(3)将返回0以指示错误,而SSL_read(3)SSL_write(3)将返回0或负值以指示错误。SSL_shutdown(3)将返回负值以指示错误。

发生错误时,应用程序应调用SSL_get_error(3)以找出发生了哪种类型的错误。如果错误是非致命的并且可以重试,则SSL_get_error(3)将返回SSL_ERROR_WANT_READSSL_ERROR_WANT_WRITE,具体取决于OpenSSL是否希望读取或写入流但无法做到。请注意,对SSL_read_ex(3)SSL_read(3)的调用仍然可以生成SSL_ERROR_WANT_WRITE。类似地,对SSL_write_ex(3)SSL_write(3)的调用可能会生成SSL_ERROR_WANT_READ

可能发生的另一种非致命错误是SSL_ERROR_ZERO_RETURN。这表示EOF(文件结束),如果尝试从SSL对象读取数据但对等方已指示它不会在流上发送更多数据,则可能会发生这种情况。在这种情况下,您可能仍希望将数据写入流,但您将不再接收任何数据。

可能发生的致命错误是SSL_ERROR_SYSCALLSSL_ERROR_SSL。这些指示流不再可用。例如,这可能是因为流已被对等方重置,或者因为底层连接已失败。您可以查阅OpenSSL错误堆栈以获取更多详细信息(例如,通过调用ERR_print_errors(3)打印发生的错误的详细信息)。您还可以查阅SSL_get_stream_read_state(3)的返回值以确定错误是特定于流的,还是底层连接也已失败。返回值SSL_STREAM_STATE_RESET_REMOTE告诉您流已被对等方重置,而SSL_STREAM_STATE_CONN_CLOSED告诉您底层连接已关闭。

在我们的演示应用程序中,我们将编写一个函数来处理来自OpenSSL I/O函数的这些错误

static int handle_io_failure(SSL *ssl, int res)
{
    switch (SSL_get_error(ssl, res)) {
    case SSL_ERROR_WANT_READ:
    case SSL_ERROR_WANT_WRITE:
        /* Temporary failure. Wait until we can read/write and try again */
        wait_for_activity(ssl);
        return 1;

    case SSL_ERROR_ZERO_RETURN:
        /* EOF */
        return 0;

    case SSL_ERROR_SYSCALL:
        return -1;

    case SSL_ERROR_SSL:
        /*
         * Some stream fatal error occurred. This could be because of a
         * stream reset - or some failure occurred on the underlying
         * connection.
         */
        switch (SSL_get_stream_read_state(ssl)) {
        case SSL_STREAM_STATE_RESET_REMOTE:
            printf("Stream reset occurred\n");
            /*
             * The stream has been reset but the connection is still
             * healthy.
             */
            break;

        case SSL_STREAM_STATE_CONN_CLOSED:
            printf("Connection closed\n");
            /* Connection is already closed. */
            break;

        default:
            printf("Unknown stream failure\n");
            break;
        }
        /*
         * If the failure is due to a verification error we can get more
         * information about it from SSL_get_verify_result().
         */
        if (SSL_get_verify_result(ssl) != X509_V_OK)
            printf("Verify error: %s\n",
                X509_verify_cert_error_string(SSL_get_verify_result(ssl)));
        return -1;

    default:
        return -1;
    }
}

此函数将表示连接的SSL对象以及失败的I/O函数的返回值作为参数。在发生非致命故障的情况下,它将等待直到可以成功重试I/O操作(通过使用我们在上一节中开发的wait_for_activity()函数)。如果发生非致命错误(除了EOF),它将返回1;如果发生EOF,它将返回0;如果发生致命错误,它将返回-1。

创建SSL_CTX和SSL对象

为了连接到服务器,我们必须为此创建SSL_CTXSSL对象。执行此操作的大多数步骤与阻塞客户端相同,并在ossl-guide-quic-client-block(7)页面上进行了说明。我们不会在这里重复这些信息。

一个关键的区别是我们必须将SSL对象置于非阻塞模式(默认模式为阻塞模式)。为此,我们使用SSL_set_blocking_mode(3)函数

/*
 * The underlying socket is always nonblocking with QUIC, but the default
 * behaviour of the SSL object is still to block. We set it for nonblocking
 * mode in this demo.
 */
if (!SSL_set_blocking_mode(ssl, 0)) {
    printf("Failed to turn off blocking mode\n");
    goto end;
}

尽管我们在此处开发的演示应用程序没有使用它,但在开发QUIC应用程序时可以使用“线程辅助模式”。通常,在编写OpenSSL QUIC应用程序时,重要的是定期在连接SSL对象上调用SSL_handle_events(3)(或者任何I/O函数)以保持连接处于健康状态。有关此内容的更多讨论,请参阅“在等待套接字时执行工作”。在编写非阻塞QUIC应用程序时,这一点尤其重要,因为在使用非阻塞模式时,通常会使SSL连接对象闲置一段时间。通过使用“线程辅助模式”,OpenSSL会创建一个单独的线程来自动执行此操作,这意味着应用程序开发人员无需处理此方面。为此,我们必须在构建SSL_CTX时使用OSSL_QUIC_client_thread_method(3),如下所示

ctx = SSL_CTX_new(OSSL_QUIC_client_thread_method());
if (ctx == NULL) {
    printf("Failed to create the SSL_CTX\n");
    goto end;
}

执行握手

与阻塞式 QUIC 客户端演示一样,我们使用 SSL_connect(3) 函数与服务器进行握手。由于我们使用的是非阻塞式 SSL 对象,因此在等待服务器响应我们的握手消息时,调用此函数很可能会失败并出现非致命错误。在这种情况下,我们必须稍后重试相同的 SSL_connect(3) 调用。在本演示中,我们使用循环来执行此操作

/* Do the handshake with the server */
while ((ret = SSL_connect(ssl)) != 1) {
    if (handle_io_failure(ssl, ret) == 1)
        continue; /* Retry */
    printf("Failed to connect to server\n");
    goto end; /* Cannot retry: error */
}

我们不断调用 SSL_connect(3),直到它返回成功响应。否则,我们将使用之前创建的 handle_io_failure() 函数来确定下一步该做什么。请注意,我们在此阶段不期望出现 EOF,因此此类响应将与致命错误以相同的方式处理。

发送和接收数据

与阻塞式 QUIC 客户端演示一样,我们使用 SSL_write_ex(3) 函数向服务器发送数据。与上面的 SSL_connect(3) 一样,因为我们使用的是非阻塞式 SSL 对象,所以此调用可能会失败并出现非致命错误。在这种情况下,我们应该再次重试完全相同的 SSL_write_ex(3) 调用。请注意,参数必须完全相同,即指向具有相同长度的写入缓冲区的相同指针。您不得尝试在重试时发送不同的数据。确实存在一个可选模式(SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER),它将配置 OpenSSL 以允许从一次重试到下一次重试写入的缓冲区发生变化。但是,在这种情况下,您仍然必须重试完全相同的数据——即使包含该数据的数据缓冲区的位置可能发生变化。有关更多详细信息,请参阅 SSL_CTX_set_mode(3)。与 TLS 教程(ossl-guide-tls-client-block(7))一样,我们将请求分成三块写入。

/* Write an HTTP GET request to the peer */
while (!SSL_write_ex(ssl, request_start, strlen(request_start), &written)) {
    if (handle_io_failure(ssl, 0) == 1)
        continue; /* Retry */
    printf("Failed to write start of HTTP request\n");
    goto end; /* Cannot retry: error */
}
while (!SSL_write_ex(ssl, hostname, strlen(hostname), &written)) {
    if (handle_io_failure(ssl, 0) == 1)
        continue; /* Retry */
    printf("Failed to write hostname in HTTP request\n");
    goto end; /* Cannot retry: error */
}
while (!SSL_write_ex(ssl, request_end, strlen(request_end), &written)) {
    if (handle_io_failure(ssl, 0) == 1)
        continue; /* Retry */
    printf("Failed to write end of HTTP request\n");
    goto end; /* Cannot retry: error */
}

在写入时,我们不期望看到 EOF 响应,因此我们将这种情况与致命错误以相同的方式处理。

从服务器读取响应类似

do {
    /*
     * Get up to sizeof(buf) bytes of the response. We keep reading until
     * the server closes the connection.
     */
    while (!eof && !SSL_read_ex(ssl, buf, sizeof(buf), &readbytes)) {
        switch (handle_io_failure(ssl, 0)) {
        case 1:
            continue; /* Retry */
        case 0:
            eof = 1;
            continue;
        case -1:
        default:
            printf("Failed reading remaining data\n");
            goto end; /* Cannot retry: error */
        }
    }
    /*
     * OpenSSL does not guarantee that the returned data is a string or
     * that it is NUL terminated so we use fwrite() to write the exact
     * number of bytes that we read. The data could be non-printable or
     * have NUL characters in the middle of it. For this simple example
     * we're going to print it to stdout anyway.
     */
    if (!eof)
        fwrite(buf, 1, readbytes, stdout);
} while (!eof);
/* In case the response didn't finish with a newline we add one now */
printf("\n");

这次的主要区别在于,当尝试从服务器读取数据时,我们可以接收 EOF 响应。当服务器在发送响应中的所有数据后关闭连接时,将发生这种情况。

在本演示中,我们仅打印出从服务器响应中接收到的所有数据。我们继续循环,直到遇到致命错误或收到 EOF(表示正常结束)。

关闭连接

与 QUIC 阻塞示例一样,我们必须在完成连接后关闭它。

即使我们已在上面读取的流上接收了 EOF,但这并不能告诉我们底层连接的状态。我们的演示应用程序将通过 SSL_shutdown(3) 启动连接关闭过程。

由于我们的应用程序正在启动关闭,因此我们可能希望看到 SSL_shutdown(3) 返回值 0,然后我们应该继续调用它,直到收到返回值 1(表示我们已成功完成关闭)。由于我们使用的是非阻塞式 SSL 对象,因此我们可能需要多次重试此操作。如果 SSL_shutdown(3) 返回负结果,则必须调用 SSL_get_error(3) 以确定下一步该做什么。为此,我们使用之前开发的 handle_io_failure() 函数

/*
 * Repeatedly call SSL_shutdown() until the connection is fully
 * closed.
 */
while ((ret = SSL_shutdown(ssl)) != 1) {
    if (ret < 0 && handle_io_failure(ssl, ret) == 1)
        continue; /* Retry */
}

最终清理

与阻塞式 QUIC 客户端示例一样,在完成连接后,我们必须释放它。本示例中执行此操作的步骤与阻塞示例相同,因此此处不再赘述。

进一步阅读

请参阅 ossl-guide-quic-client-block(7),了解有关如何编写阻塞式 QUIC 客户端的教程。请参阅 ossl-guide-quic-multi-stream(7),了解如何编写多流 QUIC 客户端。

另请参阅

ossl-guide-introduction(7)ossl-guide-libraries-introduction(7)ossl-guide-libssl-introduction(7)ossl-guide-quic-introduction(7)ossl-guide-quic-client-block(7)ossl-guide-quic-multi-stream(7)

版权所有 2023 OpenSSL 项目作者。保留所有权利。

根据 Apache 许可证 2.0(“许可证”)许可。除非符合许可证,否则您不得使用此文件。您可以在源代码分发中的 LICENSE 文件或 https://www.openssl.org/source/license.html 中获取副本。