OpenSSL

加密和 SSL/TLS 工具包

ossl-guide-quic-client-block

名称

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

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

此页面将提供各种源代码示例,演示如何编写一个简单的阻塞式 QUIC 客户端应用程序,该应用程序连接到服务器,向其发送 HTTP/1.0 请求,并读取响应。请注意,QUIC 上的 HTTP/1.0 非标准,并且不会得到现实世界服务器的支持。这仅用于演示目的。

我们假设您已经在系统上安装了 OpenSSL;您已经对 OpenSSL 概念、TLS 和 QUIC 有了一些基本了解(请参阅 ossl-guide-libraries-introduction(7)ossl-guide-tls-introduction(7)ossl-guide-quic-introduction(7));并且您知道如何编写和构建 C 代码,以及如何将其链接到 OpenSSL 提供的 libcrypto 和 libssl 库。它还假设您对 UDP/IP 和套接字有基本了解。我们将在本教程中构建的示例代码将修改 ossl-guide-tls-client-block(7) 中介绍的阻塞式 TLS 客户端示例。只会讨论该客户端与本客户端之间的差异,因此我们也假设您已经完成并理解了该教程。

在本教程中,我们的客户端将使用单个 QUIC 流。后续教程将讨论如何编写多流客户端(请参阅 ossl-guide-quic-multi-stream(7))。

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

创建 SSL_CTX 和 SSL 对象

在 TLS 教程 (ossl-guide-tls-client-block(7)) 中,我们为客户端创建了一个 SSL_CTX 对象,并使用它创建了一个 SSL 对象来表示 TLS 连接。QUIC 连接的工作方式完全相同。我们首先创建一个 SSL_CTX 对象,然后使用它创建一个 SSL 对象来表示 QUIC 连接。

与 TLS 示例一样,第一步是为我们的客户端创建一个 SSL_CTX 对象。这与之前的方法相同,只是我们使用了不同的“方法”。OpenSSL 提供了两种不同的 QUIC 客户端方法,即 OSSL_QUIC_client_method(3)OSSL_QUIC_client_thread_method(3)

第一个方法等效于 TLS_client_method(3),但用于 QUIC 协议。第二个方法相同,但它还会另外创建一个后台线程来处理基于时间的事务(称为“线程辅助模式”,请参阅 ossl-guide-quic-introduction(7))。在本教程中,我们将使用 OSSL_QUIC_client_method(3),因为我们不会在应用程序中让 QUIC 连接保持空闲状态,因此不需要线程辅助模式。

/*
 * Create an SSL_CTX which we can use to create SSL objects from. We
 * want an SSL_CTX for creating clients so we use OSSL_QUIC_client_method()
 * here.
 */
ctx = SSL_CTX_new(OSSL_QUIC_client_method());
if (ctx == NULL) {
    printf("Failed to create the SSL_CTX\n");
    goto end;
}

我们应用于 TLS 的 SSL_CTX 的其他设置步骤也适用于 QUIC,但限制我们愿意接受的 TLS 版本除外。OpenSSL 中的 QUIC 协议实现目前仅支持 TLSv1.3。在 OpenSSL QUIC 应用程序中无需调用 SSL_CTX_set_min_proto_version(3)SSL_CTX_set_max_proto_version(3),并且任何此类调用都将被忽略。

创建 SSL_CTX 后,SSL 对象的构建方式与 TLS 应用程序完全相同。

创建套接字和 BIO

TLS 和 QUIC 之间的一个主要区别是底层传输协议。TLS 使用 TCP,而 QUIC 使用 UDP。在我们的示例代码中创建 QUIC 套接字的方式与 TLS 非常相似。我们使用 BIO_lookup_ex(3)BIO_socket(3) 辅助函数,就像我们在上一教程中所做的那样,只是我们将 SOCK_DGRAM 作为参数传递以指示 UDP(而不是 TCP 的 SOCK_STREAM)。

/*
 * Lookup IP address info for the server.
 */
if (!BIO_lookup_ex(hostname, port, BIO_LOOKUP_CLIENT, family, SOCK_DGRAM, 0,
                   &res))
    return NULL;

/*
 * Loop through all the possible addresses for the server and find one
 * we can connect to.
 */
for (ai = res; ai != NULL; ai = BIO_ADDRINFO_next(ai)) {
    /*
     * Create a TCP socket. We could equally use non-OpenSSL calls such
     * as "socket" here for this and the subsequent connect and close
     * functions. But for portability reasons and also so that we get
     * errors on the OpenSSL stack in the event of a failure we use
     * OpenSSL's versions of these functions.
     */
    sock = BIO_socket(BIO_ADDRINFO_family(ai), SOCK_DGRAM, 0, 0);
    if (sock == -1)
        continue;

    /* Connect the socket to the server's address */
    if (!BIO_connect(sock, BIO_ADDRINFO_address(ai), 0)) {
        BIO_closesocket(sock);
        sock = -1;
        continue;
    }

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

    break;
}

if (sock != -1) {
    *peer_addr = BIO_ADDR_dup(BIO_ADDRINFO_address(ai));
    if (*peer_addr == NULL) {
        BIO_closesocket(sock);
        return NULL;
    }
}

/* Free the address information resources we allocated earlier */
BIO_ADDRINFO_free(res);

您可能会注意到此代码与我们用于 TLS 的版本之间存在一些其他差异。

首先,我们将套接字设置为非阻塞模式。对于 OpenSSL QUIC 应用程序,必须始终执行此操作。考虑到我们试图编写一个阻塞式客户端,这可能令人惊讶。尽管如此,SSL 对象仍将具有阻塞行为。有关此方面的更多信息,请参阅 ossl-guide-quic-introduction(7)

其次,我们记下正在连接到的对等方的 IP 地址。我们将该信息存储起来。我们稍后将需要它。

有关此处使用的函数的更多信息,请参阅 BIO_lookup_ex(3)BIO_socket(3)BIO_connect(3)BIO_closesocket(3)BIO_ADDRINFO_next(3)BIO_ADDRINFO_address(3)BIO_ADDRINFO_free(3)BIO_ADDR_dup(3)。在上面的示例代码中,hostnameport 变量是字符串,例如“www.example.com”和“443”。

至于我们的 TLS 客户端,一旦套接字创建并连接,我们就需要将其与 BIO 对象关联起来

BIO *bio;

/* Create a BIO to wrap the socket */
bio = BIO_new(BIO_s_datagram());
if (bio == NULL) {
    BIO_closesocket(sock);
    return NULL;
}

/*
 * Associate the newly created BIO with the underlying socket. By
 * passing BIO_CLOSE here the socket will be automatically closed when
 * the BIO is freed. Alternatively you can use BIO_NOCLOSE, in which
 * case you must close the socket explicitly when it is no longer
 * needed.
 */
BIO_set_fd(bio, sock, BIO_CLOSE);

请注意此处使用的是 BIO_s_datagram(3),而不是我们用于 TLS 客户端的 BIO_s_socket(3)。这再次是因为 QUIC 使用 UDP 而不是 TCP 作为其传输层。有关这些函数的更多信息,请参阅 BIO_new(3)BIO_s_datagram(3)BIO_set_fd(3)

设置服务器的主机名

与 TLS 教程一样,我们需要为 SNI(服务器名称指示)和证书验证目的设置服务器的主机名。此步骤与 TLS 教程相同,此处不再赘述。

设置 ALPN

ALPN(应用程序层协议协商)是 TLS 的一项功能,它使应用程序能够协商将在连接上使用的协议。例如,如果您打算在连接上使用 HTTP/3,则其 ALPN 值为“h3”(请参阅 https://www.iana.org/assignments/tls-extensiontype-values/tls-extensiontype-values.xml#alpn-protocol-ids)。OpenSSL 为客户端提供了通过 SSL_set_alpn_protos(3) 函数指定要使用的 ALPN 的能力。对于 TLS 客户端,这是可选的,因此我们在 ossl-guide-tls-client-block(7) 中开发的简单客户端没有使用它。但是,QUIC 要求用于建立 QUIC 连接的 TLS 握手必须使用 ALPN。

unsigned char alpn[] = { 8, 'h', 't', 't', 'p', '/', '1', '.', '0' };

/* SSL_set_alpn_protos returns 0 for success! */
if (SSL_set_alpn_protos(ssl, alpn, sizeof(alpn)) != 0) {
    printf("Failed to set the ALPN for the connection\n");
    goto end;
}

ALPN 使用长度前缀的无符号字符数组指定(它不是以 NUL 结尾的字符串)。我们最初的 TLS 阻塞式客户端演示使用的是 HTTP/1.0。在本示例中,我们将使用相同的协议。与大多数 OpenSSL 函数不同,SSL_set_alpn_protos(3) 在成功时返回零,在失败时返回非零值。

设置对等方地址

OpenSSL QUIC 应用程序必须指定要连接到的服务器的目标地址。在上面 "创建套接字和 BIO" 中,我们保存了该地址以备将来使用。现在,我们需要通过 SSL_set1_initial_peer_addr(3) 函数使用它。

/* Set the IP address of the remote peer */
if (!SSL_set1_initial_peer_addr(ssl, peer_addr)) {
    printf("Failed to set the initial peer address\n");
    goto end;
}

请注意,我们将需要释放之前通过 BIO_ADDR_dup(3) 分配的 peer_addr

BIO_ADDR_free(peer_addr);

握手和应用程序数据传输

完成 SSL 对象的初始设置后,我们将通过 SSL_connect(3) 执行握手,这与我们对 TLS 客户端所做的一样,因此我们在此不再赘述。

我们还可以使用默认的 QUIC 流执行数据传输,该流会自动与我们的 SSL 对象关联。我们可以使用 SSL_write_ex(3) 传输数据,并使用 SSL_read_ex(3) 接收数据,这与 TLS 的方式相同。主要区别在于我们必须以略微不同的方式处理故障。使用 QUIC,流可以被对等方重置(这对该流来说是致命的),但底层连接本身可能仍然处于健康状态。

/*
 * Get up to sizeof(buf) bytes of the response. We keep reading until the
 * server closes the connection.
 */
while (SSL_read_ex(ssl, 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");

/*
 * 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(ssl, 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(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. 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;
}

在上面的代码示例中,您可以看到 SSL_ERROR_SSL 表示流致命错误。我们可以使用 SSL_get_stream_read_state(3) 来确定流是否已被重置,或者是否发生了其他致命错误。

关闭连接

在 TLS 教程中,我们知道服务器已完成数据发送,因为 SSL_read_ex(3) 返回 0,并且 SSL_get_error(3) 返回 SSL_ERROR_ZERO_RETURN。QUIC 也是如此,只是 SSL_ERROR_ZERO_RETURN 的解释略有不同。使用 TLS,我们知道这意味着服务器已发送“close_notify”警报。服务器将不再在该连接上发送任何数据。

使用 QUIC,这意味着服务器已在流上指示“FIN”,这意味着它将不再在该流上发送任何更多数据。但是,这仅向我们提供有关流本身的信息,而没有告诉我们有关底层连接的任何信息。服务器仍可能在其他一些流上发送更多数据。此外,尽管服务器不会再向客户端发送任何数据,但这并不能阻止客户端向服务器发送更多数据。

在本教程中,一旦我们完成从我们在使用的单个流上的服务器读取数据,我们将关闭连接。与之前一样,我们通过 SSL_shutdown(3) 函数执行此操作。此 QUIC 示例与 TLS 版本非常相似。但是,需要多次调用 SSL_shutdown(3) 函数

/*
 * Repeatedly call SSL_shutdown() until the connection is fully
 * closed.
 */
do {
    ret = SSL_shutdown(ssl);
    if (ret < 0) {
        printf("Error shutting down: %d\n", ret);
        goto end;
    }
} while (ret != 1);

关闭过程分两个阶段进行。在第一阶段,我们等待在任何流上缓冲的所有发送数据都已成功发送并得到对等方的确认,然后我们向对等方发送 CONNECTION_CLOSE 以指示连接不再可用。这会立即关闭连接,并且不再可以发送或接收任何数据。一旦第一阶段完成,SSL_shutdown(3) 将返回 0。

在第二阶段,连接进入“关闭”状态。在此状态下,无法发送或接收应用程序数据,但来自对等方的延迟到达的数据包将得到适当处理。一旦此阶段成功完成,SSL_shutdown(3) 将返回 1 以指示成功。

进一步阅读

请参阅 ossl-guide-quic-multi-stream(7),以阅读有关如何修改此页面上开发的客户端以支持多个流的教程。

另请参阅

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-introduction(7)

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

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