OpenSSL

密码学和 SSL/TLS 工具包

ossl-guide-quic-multi-stream

名称

ossl-guide-quic-multi-stream - OpenSSL 指南:编写一个简单的多流 QUIC 客户端

简介

此页面将介绍编写简单的 QUIC 多流应用程序所需的一些重要概念。它假设您已经了解 QUIC 及其在 OpenSSL 中的使用方式。请参阅 ossl-guide-quic-introduction(7)ossl-guide-quic-client-block(7)

QUIC 流

在 QUIC 多流应用程序中,我们将 QUIC 的“连接”和 QUIC 的“流”的概念分开。连接对象表示客户端和服务器之间连接的总体细节,包括其所有协商和配置的参数。在 OpenSSL 应用程序中,我们使用 SSL 对象来表示(称为连接 SSL 对象)。它由应用程序调用 SSL_new(3) 创建。

单独地,一个连接可以有零个或多个流与其关联(尽管一个没有流的连接可能不是很有用,所以通常您至少需要一个)。流用于在两个对等体之间发送和接收数据。每个流也由一个 SSL 对象表示。一个流在逻辑上独立于与同一连接关联的所有其他流。在该流中发送的数据保证按发送顺序传递。跨流则不然,例如,如果应用程序首先在流 1 上发送数据,然后在流 2 上发送更多数据,则远程对等体可能会在接收流 1 上发送的数据之前接收流 2 上发送的数据。

一旦连接 SSL 对象完成了其握手(即 SSL_connect(3) 返回 1),流 SSL 对象就会由应用程序调用 SSL_new_stream(3)SSL_accept_stream(3) 创建(请参阅下面的 “创建新的流”)。

与大多数 OpenSSL 对象一样,相同的线程规则也适用于 SSL 对象(请参阅 ossl-guide-libraries-introduction(7))。特别是大多数 OpenSSL 函数是线程安全的,但 SSL 对象不是。这意味着您可以同时使用一个表示一个流的 SSL 对象,而另一个线程正在使用同一个连接上不同流的不同 SSL 对象。但是,您不能同时在两个不同的线程上使用同一个 SSL 对象(除非使用额外的应用程序级锁)。

默认流

连接 SSL 对象还可以(可选地)与一个流关联。此流称为默认流。当应用程序调用 SSL_read_ex(3)SSL_read(3)SSL_write_ex(3)SSL_write(3) 并将连接 SSL 对象作为参数传递时,会自动创建默认流并将其与 SSL 对象关联。

如果客户端应用程序首先调用 SSL_write_ex(3)SSL_write(3),则(默认情况下)默认流将是客户端发起的双向流。如果客户端应用程序首先调用 SSL_read_ex(3)SSL_read(3),则服务器发起的第一个流将用作默认流(无论它是双向还是单向)。

可以通过默认流模式来控制此行为。有关更多详细信息,请参阅 SSL_set_default_stream_mode(3)

建议新的多流应用程序根本不使用默认流,而是为使用的每个流使用单独的流 SSL 对象。这需要调用 SSL_set_default_stream_mode(3) 并将模式设置为 SSL_DEFAULT_STREAM_MODE_NONE

创建新的流

端点可以通过调用 SSL_new_stream(3) 创建一个新的流。这将创建一个本地发起的流。为此,您必须将 QUIC 连接 SSL 对象作为参数传递。您还可以指定是否需要双向或单向流。

该函数返回一个新的 QUIC 流 SSL 对象,用于在该流上发送和接收数据。

对等体也可以发起流。应用程序可以使用函数 SSL_get_accept_stream_queue_len(3) 来确定对等体发起的等待应用程序处理的流的数量。应用程序可以调用 SSL_accept_stream(3) 为远程发起的流创建一个新的 SSL 对象。如果对等体没有发起任何流,则如果连接对象处于阻塞模式(请参阅 SSL_set_blocking_mode(3)),此调用将阻塞,直到有一个流可用。

当使用默认流时,OpenSSL 将阻止接受新流。要覆盖此行为,您必须调用 SSL_set_incoming_stream_policy(3) 将策略设置为 SSL_INCOMING_STREAM_POLICY_ACCEPT。有关更多详细信息,请参阅手册页。如果如上面 “默认流” 中所述禁用了默认流,则这与之无关。

任何流都可以是双向或单向的。如果它是单向的,则发起者可以写入它但不能从中读取,反之亦然。您可以通过调用 SSL_get_stream_type(3) 来确定 SSL 对象表示哪种类型的流。有关更多详细信息,请参阅手册页。

使用流发送和接收数据

一旦您拥有了一个流 SSL 对象(如果使用默认流,则包括连接 SSL 对象),您就可以使用 SSL_write_ex(3)SSL_write(3)SSL_read_ex(3)SSL_read(3) 函数通过它发送和接收数据。有关更多详细信息,请参阅手册页。

如果这些函数之一没有返回成功代码,则应调用 SSL_get_error(3) 以获取有关错误的更多详细信息。在阻塞模式下,这将是一个致命错误(例如 SSL_ERROR_SYSCALLSSL_ERROR_SSL),或者它将是 SSL_ERROR_ZERO_RETURN,当尝试从流中读取数据并且对等体已指示流已结束(即“FIN”已在流上发出信号)时可能会发生这种情况。这意味着对等体不会在该流上发送更多数据。请注意,与 TLS 应用程序相比,QUIC 应用程序对 SSL_ERROR_ZERO_RETURN 的解释略有不同。在 TLS 中,当连接被对等体关闭时发生这种情况。在 QUIC 中,这只会告诉您当前流已由对等体结束。它不会告诉您有关底层连接的任何信息。如果对等体已结束流,则不会在该流上接收更多数据,但是应用程序仍然可以向对等体发送数据,直到流的发送端也已结束。这可以通过应用程序调用 SSL_stream_conclude(3) 来实现。在调用 SSL_stream_conclude(3) 后尝试在流上发送更多数据是错误的。

还可以通过调用 SSL_stream_reset(3) 异常放弃流。

一旦不再需要流对象,就应该通过调用 SSL_free(3) 来释放它。应用程序不应在其上调用 SSL_shutdown(3),因为这仅对连接级 SSL 对象有意义。释放流将自动向对等体发出 STOP_SENDING 信号。

流和连接

给定一个流对象,可以通过调用 SSL_get0_connection(3) 获取与连接对应的 SSL 对象。多线程限制适用,因此在使用返回的连接对象时应小心。具体来说,如果您在不同的线程中处理每个流对象并在该线程中调用 SSL_get0_connection(3),则必须注意不要在其他线程也使用该连接对象的同时调用任何使用该连接对象的函数(例外情况是 SSL_accept_stream(3)SSL_get_accept_stream_queue_len(3),它们是线程安全的)。

流对象不会从其父 SSL 连接对象继承其所有设置和值。因此,与整体连接相关的某些函数调用将无法在流上工作。例如,函数 SSL_get_certificate(3) 可用于获取对等体证书的句柄,当使用连接 SSL 对象调用时。当使用流 SSL 对象调用时,它将返回 NULL。

简单的多流 QUIC 客户端示例

本节将提供各种源代码示例,演示如何编写简单的多流 QUIC 客户端应用程序,该应用程序连接到服务器,向其发送一些 HTTP/1.0 请求,并读取响应。请注意,HTTP/1.0 通过 QUIC 不是标准的,并且不会得到现实世界服务器的支持。这仅用于演示目的。

我们将基于 ossl-guide-quic-client-block(7) 页面上介绍的简单阻塞 QUIC 客户端的示例代码进行构建,并假设您熟悉它。我们只会描述简单阻塞 QUIC 客户端和多流 QUIC 客户端之间的区别。虽然示例代码使用阻塞 SSL 对象,但您也可以同样使用非阻塞 SSL 对象。有关编写非阻塞 QUIC 客户端的更多信息,请参阅 ossl-guide-quic-client-non-block(7)

此示例多流 QUIC 客户端的完整源代码可在 OpenSSL 源代码分发包的 demos/guide 目录中的 quic-multi-stream.c 文件中找到。它也可以在 https://github.com/openssl/openssl/blob/master/demos/guide/quic-multi-stream.c 在线获取。

禁用默认流

如上面 “默认流” 中所述,我们将遵循建议,为我们的多流客户端禁用默认流。为此,我们调用 SSL_set_default_stream_mode(3) 函数并将我们的连接 SSL 对象和值 SSL_DEFAULT_STREAM_MODE_NONE 传递给它。

/*
 * We will use multiple streams so we will disable the default stream mode.
 * This is not a requirement for using multiple streams but is recommended.
 */
if (!SSL_set_default_stream_mode(ssl, SSL_DEFAULT_STREAM_MODE_NONE)) {
    printf("Failed to disable the default stream mode\n");
    goto end;
}

创建请求流

出于本示例的目的,我们将创建两个不同的流以向服务器发送两个不同的 HTTP 请求。为了演示,第一个流将是双向流,第二个流将是单向流。

/*
 * We create two new client initiated streams. The first will be
 * bi-directional, and the second will be uni-directional.
 */
stream1 = SSL_new_stream(ssl, 0);
stream2 = SSL_new_stream(ssl, SSL_STREAM_FLAG_UNI);
if (stream1 == NULL || stream2 == NULL) {
    printf("Failed to create streams\n");
    goto end;
}

向流写入数据

成功创建流后,我们可以开始向其写入数据。在本例中,我们将在每个流上发送不同的 HTTP 请求。为了避免重复太多代码,我们编写了一个简单的辅助函数来将 HTTP 请求发送到流。

int write_a_request(SSL *stream, const char *request_start,
                    const char *hostname)
{
    const char *request_end = "\r\n\r\n";
    size_t written;

    if (!SSL_write_ex(stream, request_start, strlen(request_start), &written))
        return 0;
    if (!SSL_write_ex(stream, hostname, strlen(hostname), &written))
        return 0;
    if (!SSL_write_ex(stream, request_end, strlen(request_end), &written))
        return 0;

    return 1;
}

我们假设字符串request1_startrequest2_start包含适当的HTTP请求。然后,我们可以调用上面定义的辅助函数,在两个流上发送这些请求。为了简单起见,此示例按顺序执行此操作,首先写入stream1,然后在成功后写入stream2。请记住,我们的客户端是阻塞的,因此这些调用只有在成功完成之后才会返回。实际应用不需要按顺序或以任何特定顺序执行这些写入操作。例如,我们可以启动两个线程(每个流一个),并同时将请求写入每个流。

/* Write an HTTP GET request on each of our streams to the peer */
if (!write_a_request(stream1, request1_start, hostname)) {
    printf("Failed to write HTTP request on stream 1\n");
    goto end;
}

if (!write_a_request(stream2, request2_start, hostname)) {
    printf("Failed to write HTTP request on stream 2\n");
    goto end;
}

从流中读取数据

在此示例中,stream1是一个双向流,因此,一旦我们在其上发送了请求,我们就可以尝试读取服务器返回的响应。在这里,我们只是重复调用SSL_read_ex(3),直到该函数失败(表示出现问题或对等方已发出流结束信号)。

printf("Stream 1 data:\n");
/*
 * Get up to sizeof(buf) bytes of the response from stream 1 (which is a
 * bidirectional stream). We keep reading until the server closes the
 * connection.
 */
while (SSL_read_ex(stream1, buf, sizeof(buf), &readbytes)) {
    /*
    * 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.
    */
    fwrite(buf, 1, readbytes, stdout);
}
/* In case the response didn't finish with a newline we add one now */
printf("\n");

在像这样的阻塞应用程序中,对SSL_read_ex(3)的调用要么立即成功并返回已有的数据,要么会阻塞等待更多数据变为可用并返回数据,或者会返回0响应代码并失败。

退出上面的while循环后,我们知道对SSL_read_ex(3)的最后一次调用返回了0响应代码,因此我们调用SSL_get_error(3)函数以获取更多详细信息。由于这是一个阻塞应用程序,因此它将返回SSL_ERROR_SYSCALLSSL_ERROR_SSL表示基本问题,或者返回SSL_ERROR_ZERO_RETURN表示流已结束,并且将不再有可供读取的数据。必须注意区分流级别的错误(例如,流重置)和连接级别的错误(例如,连接关闭)。SSL_get_stream_read_state(3)函数可用于区分这些不同情况。

/*
 * Check whether we finished the while loop above normally or as the
 * result of an error. The 0 argument to SSL_get_error() is the return
 * code we received from the SSL_read_ex() call. It must be 0 in order
 * to get here. Normal completion is indicated by SSL_ERROR_ZERO_RETURN. In
 * QUIC terms this means that the peer has sent FIN on the stream to
 * indicate that no further data will be sent.
 */
switch (SSL_get_error(stream1, 0)) {
case SSL_ERROR_ZERO_RETURN:
    /* Normal completion of the stream */
    break;

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(stream1)) {
    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. Skip SSL_shutdown() */
        goto end;

    default:
        printf("Unknown stream failure\n");
        break;
    }
    break;

default:
    /* Some other unexpected error occurred */
    printf ("Failed reading remaining data\n");
    break;
}

接受传入流

我们上面创建的stream2对象是一个单向流,因此无法用于接收来自服务器的数据。在此假设示例中,我们假设服务器启动一个新的流来将我们请求的数据发送回我们。为此,我们调用SSL_accept_stream(3)。由于这是一个阻塞应用程序,因此它将无限期地等待,直到新流到达并可供我们接受。如果发生错误,它将返回NULL

/*
 * In our hypothetical HTTP/1.0 over QUIC protocol that we are using we
 * assume that the server will respond with a server initiated stream
 * containing the data requested in our uni-directional stream. This doesn't
 * really make sense to do in a real protocol, but its just for
 * demonstration purposes.
 *
 * We're using blocking mode so this will block until a stream becomes
 * available. We could override this behaviour if we wanted to by setting
 * the SSL_ACCEPT_STREAM_NO_BLOCK flag in the second argument below.
 */
stream3 = SSL_accept_stream(ssl, 0);
if (stream3 == NULL) {
    printf("Failed to accept a new stream\n");
    goto end;
}

我们现在可以像上面对stream1所做的那样从流中读取数据。我们这里不再重复。

清理流

完成使用流后,我们可以简单地通过调用SSL_free(3)来释放它们。或者,如果我们想指示对等方我们不会再向其发送任何数据,则可以在其上调用SSL_stream_conclude(3),但我们在此示例中不这样做,因为我们假设HTTP应用程序协议提供了足够的信息,使对等方知道我们何时完成发送请求数据。

我们不应在流对象上调用SSL_shutdown(3)SSL_shutdown_ex(3),因为这些调用不应用于流。

SSL_free(stream1);
SSL_free(stream2);
SSL_free(stream3);

另请参见

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)

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

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