OpenSSL

密码学和 SSL/TLS 工具包

ossl-guide-tls-client-non-block

名称

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

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

此页面将基于在 ossl-guide-tls-client-block(7) 页面上开发的示例,该示例演示了如何编写一个简单的阻塞 TLS 客户端。在此页面上,我们将修改该演示代码,使其支持非阻塞套接字。

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

正如我们在前面的示例中所看到的,阻塞套接字是一种当您尝试从它读取数据时,但尚未有数据可供读取,它会等待(阻塞)直到数据可用。类似地,如果套接字当前无法写入,它将在写入时等待。这可以简化代码的开发,因为您无需担心这些情况下的处理方式。代码的执行将简单地停止,直到它能够继续。但是,在许多情况下,您不希望出现这种行为。您的应用程序可能需要在套接字无法读取/写入时执行其他任务,例如更新 GUI 或对其他套接字执行操作,而不是停止和等待。

使用非阻塞套接字时,尝试读取或写入当前无法读取或写入的套接字将立即返回一个非致命错误。尽管 OpenSSL 对套接字进行读取/写入,但这种非阻塞行为会传播到应用程序,因此 OpenSSL I/O 函数(例如 SSL_read_ex(3)SSL_write_ex(3))不会阻塞。

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

将套接字设置为非阻塞

编写支持非阻塞的应用程序的第一步是将套接字设置为非阻塞模式。套接字默认情况下是阻塞的。执行此操作的确切细节可能因平台而异。幸运的是,OpenSSL 提供了一个可移植函数,可以为您执行此操作

/* Set to nonblocking mode */
if (!BIO_socket_nbio(sock, 1)) {
    sock = -1;
    continue;
}

您不必为此使用 OpenSSL 的函数。当然,您可以直接调用您的操作系统在您平台上为此目的提供的任何函数。

在等待套接字时执行工作

在非阻塞应用程序中,您需要执行工作以防我们想读取或写入套接字,但我们当前无法执行此操作。事实上,这就是使用非阻塞套接字的全部目的,即让应用程序有机会做其他事情。无论应用程序必须做什么,它都必须准备好定期返回并重试之前尝试的操作,以查看它现在是否可以完成。理想情况下,它只会在此操作能够执行的操作的基础套接字状态实际改变时(例如,在之前不可读的情况下变得可读)才执行此操作,但这并非必须如此。它可以在任何时候重试。

请注意,您必须重试与上次尝试完全相同的操作。您不能开始新的操作。例如,如果您正在尝试写入文本“Hello World”,并且操作失败,因为套接字当前无法写入,那么在重试操作时,您就不能尝试写入其他文本。

在此演示应用程序中,我们将创建一个辅助函数,该函数模拟执行其他工作。事实上,为了简单起见,它除了等待套接字状态改变之外什么也不做。

我们称我们的函数为 wait_for_activity(),因为它只做一件事,那就是等待基础套接字变得可读或可写,而之前它不是这样。

static void wait_for_activity(SSL *ssl, int write)
{
    fd_set fds;
    int width, sock;

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

    FD_ZERO(&fds);
    FD_SET(sock, &fds);
    width = sock + 1;

    /*
     * 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 add a 100ms timeout
     * in the last parameter to "select" below. Then, when select returns,
     * you check if it did so because of activity on the file descriptors or
     * because of the timeout. If it is due to the timeout then update the
     * GUI and then restart the "select".
     */
    if (write)
        select(width, NULL, &fds, NULL, NULL);
    else
        select(width, &fds, NULL, NULL, NULL);
}

在此示例中,我们使用 select 函数,因为它非常易于使用,并且在大多数操作系统上都可用。但是,您可以使用任何其他类似的函数来执行相同的操作。select 等待基础套接字的状态变得可读/可写,然后再返回。它还支持“超时”(大多数其他类似函数也支持),因此在您自己的应用程序中,您可以利用它来定期唤醒并在等待套接字状态改变时执行工作。但是,为了简单起见,我们在此示例中不使用该超时功能。

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

使用非阻塞套接字的应用程序需要准备好处理从 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,因为 OpenSSL 可能需要写入协议消息(例如,更新加密密钥),即使应用程序只是试图读取数据。同样,对 SSL_write_ex(3)SSL_write(3) 的调用可能会生成 SSL_ERROR_WANT_READ

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

可能发生的致命错误是 SSL_ERROR_SYSCALLSSL_ERROR_SSL。这些指示基础连接已失败。您不应尝试使用 SSL_shutdown(3) 关闭它。SSL_ERROR_SYSCALL 指示 OpenSSL 试图进行系统调用,但失败了。您可以咨询 errno 以获取更多详细信息。SSL_ERROR_SSL 指示发生了 OpenSSL 错误。您可以咨询 OpenSSL 错误堆栈以获取更多详细信息(例如,通过调用 ERR_print_errors(3) 打印已发生的错误的详细信息)。

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

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

    case SSL_ERROR_WANT_WRITE:
        /* Temporary failure. Wait until we can write and try again */
        wait_for_activity(ssl, 1);
        return 1;

    case SSL_ERROR_ZERO_RETURN:
        /* EOF */
        return 0;

    case SSL_ERROR_SYSCALL:
        return -1;

    case SSL_ERROR_SSL:
        /*
        * 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-tls-client-block(7) 页面上进行了说明。我们不会在此重复这些信息。

执行握手

与阻塞 TLS 客户端的演示一样,我们使用 SSL_connect(3) 函数与服务器执行 TLS 握手。由于我们使用的是非阻塞套接字,因此很可能对该函数的调用会在我们等待服务器响应我们的握手消息时出现非致命错误。在这种情况下,我们必须在稍后重新尝试相同的 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,因此这种响应将与致命错误一样对待。

发送和接收数据

与阻塞 TLS 客户端演示一样,我们使用 SSL_write_ex(3) 函数将数据发送到服务器。与上面的 SSL_connect(3) 一样,由于我们使用的是非阻塞套接字,因此此调用可能会出现非致命错误。在这种情况下,我们应该重新尝试完全相同的 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(表示优雅的完成)。

关闭连接

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

如果我们的应用程序是启动关闭操作,那么我们希望看到 SSL_shutdown(3) 返回值为 0,然后我们将继续调用它,直到我们收到返回值 1(表示我们已成功完成关闭操作)。在此特定示例中,我们不希望 SSL_shutdown() 返回 0,因为我们已经从服务器收到了 EOF,表示它已经关闭。因此,我们只不断调用它,直到 SSL_shutdown() 返回 1。由于我们使用的是非阻塞套接字,因此我们可能希望多次重试此操作。如果 SSL_shutdown(3) 返回负结果,那么我们必须调用 SSL_get_error(3) 以确定下一步该怎么做。我们将使用之前开发的 handle_io_failure() 函数来执行此操作

/*
 * The peer already shutdown gracefully (we know this because of the
 * SSL_ERROR_ZERO_RETURN (i.e. EOF) above). We should do the same back.
 */
while ((ret = SSL_shutdown(ssl)) != 1) {
    if (ret < 0 && handle_io_failure(ssl, ret) == 1)
        continue; /* Retry */
    /*
     * ret == 0 is unexpected here because that means "we've sent a
     * close_notify and we're waiting for one back". But we already know
     * we got one from the peer because of the SSL_ERROR_ZERO_RETURN
     * (i.e. EOF) above.
     */
    printf("Error shutting down\n");
    goto end; /* Cannot retry: error */
}

最终清理

与阻塞 TLS 客户端示例一样,完成连接后,我们必须释放它。执行此操作的步骤与阻塞示例相同,因此我们不会在此重复它。

进一步阅读

参见 ossl-guide-tls-client-block(7) 阅读有关如何编写阻塞式 TLS 客户端的教程。参见 ossl-guide-quic-client-block(7) 了解如何对 QUIC 客户端执行相同的操作。

另请参见

ossl-guide-introduction(7), ossl-guide-libraries-introduction(7), ossl-guide-libssl-introduction(7), ossl-guide-tls-introduction(7), ossl-guide-tls-client-block(7), ossl-guide-quic-client-block(7)

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

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